计算机中断体系二:中断处理
前文介绍了中断的硬件基础,今天我们深入了解一下中断的软件逻辑和不同操作系统的中断策略以及UEFI中的中断实现,最后我们澄清些常常混淆的概念。
中断处理流程
正如我们前文讲到的,中断处理在8086时代就已经引入PC。我们就从这个“远古”的鼻祖说起。
中断向量表
你有没有好奇过,0内存地址开始放了些什么东西呢?毕竟是最开始就要用的东西,一定非常重要!没错,那里就是中断向量表的家。在8086开始,中断向量表就占据这里,甚至在我们最新时髦的酷睿x代,它们还在这里。想不想看看这个顽固的家伙的样子?写个简单的 程序:
long *p = (long *) 0;
printf(“%x”, *p);
运行下看看。不出所料你的程序将产生一个异常,导致被强制关闭。还记得我们前面讲过地址转换,这个0地址是虚拟地址而不是物理地址,在保护模式下0的虚拟地址访问会产生一个异常,你是访问不到物理0地址的。我们在内核模式使用一些技巧或者我们进入实模式,我们才能看到它们。每个中断向量(vector)占据4个字节,Intel定义了256个向量,共用去1KB的内存空间。每个向量朴实无华,就是一个地址,指向该中断(INT)处理函数的入口,这也是它起名vector的原因。整个中断向量表就是一个大函数指针表,一个中断发生,CPU硬件就来这里查表,跳到相应地址就行了,好方便!实际情况稍微复杂点,CPU还要保护现场,将当前环境保存起来(一些寄存器和返回地址等压栈),以便处理完后返回现场继续执行。不过相对于我们接下来的保护模式的中断处理简单很多。没错,这个史前遗迹只在实模式发挥作用,我们只草草看看它的样子就行了:
这里主要是异常处理
硬件中断向量一区。和我们上文的8259连接两相对照一下:
没错,这里就是主8259的IRQ的中断向量区。接下来就是BIOS保留区,作为介绍BIOS的专区,这里必须看一下:
PC传统Legacy BIOS的服务例程都在这里。看到它们不胜唏嘘,曾经INT 10H打字和INT 13H读写磁盘的美(YUAN)好(SHI)日子又浮现在眼前。。。不好,这是个暴露年龄的话题,后面我们就不讲了,下课。
开个玩笑,不过中断向量表今天也只在UEFI BIOS为了兼容传统OS启动的CSM模块中起作用,我们大致了解一下其中的原理即可。
2
中断描述符
在PC进入保护模式,一个复杂但有很多妙处的机制代替了中断向量表,它就是中断描述符表(IDT,interrupt Descriptor Table)。IDT将每个中断或者异常与它的服务例程连接起来。IDT不再固定放在某个位置,而是可以放在IDTR寄存器指向的任意内存(说是任意,也不能太随性,有些小要求,如8字节对齐等),IDT的表项也从4个字节扩展到8个字节,大小也可以不满256,IDTR也指出了它的最大限制。如图:
IDT除了和中断向量一样指向一个例程地址之外,还包括其他一些信息:
其中的DPL(描述符特权级)与CS寄存器的CPL完成特权级的检查,可以避免低特权级的代码通过软件中断形式提权。它的运作形式和中断向量表类似,更多的是安全检查和可能的执行环境切换(例如ring 3 -> ring 0)。
3
中断和异常
ARM体系中断和异常是单独处理的,IRQ中断只是异常列表里的一项而已。X86中断和异常处理却混杂在一起,使用同一套机制,看似比较混乱。其实异常往往是处理器内部发生的,是同步的;而中断却是外部事件,是异步的。而它们的分布也是不同的,0到31号向量保留给异常,而更高的则往往是硬件中断和软件中断。异常分为三种:错误,陷阱和中止。这三种类型CPU对它们有不同的处理原则:错误往往是可以恢复的,错误修正后再执行刚才的错误就不会出问题了,改了就是好同志嘛!例如常用于内存管理的缺页异常,OS常常把内存换出到硬盘,它会在页表上动些手脚,CPU再次访问这块内存会发生异常,OS页面错误异常例程捕获到后赶紧把内存换回来,然后返回原处执行,就像没事发生一样。陷阱是留给软件挖坑的,CPU希望软件自己挖的坑自己能填上,它可以装作没看见,从下条继续。典型的例子是INT 3,我们的几乎所有调试工具(VS,windbg,甚至UEFI的source level debugger)都用它添加软件断点。中止就严重了,意味着发生硬件错误了,它往往能造成Windows蓝屏,linux panic等。异常一览表如下:
20到31被预留将来使用。硬件中断往往就从32开始。
4
中断优先级
PIC模式IRQ数目越低就意味着优先级越高。而在APIC模式下,IOAPIC连接的24个IRQ是平权的,先后并不关乎优先级高低。决定中断优先级的是它对应的中断向量的大小,X86体系有256个vector, 中断优先级的计算公式是:
优先级 = vector num / 16
即每16个中断一组,共享一个优先级,共16个。因为32以下vector被异常和保留占据,2到15是中断的优先级。数字越大越高优先级。中断优先级的控制是靠LAPIC的TPR(Task Priority Register,任务优先级寄存器)来控制的,它的结构如下:
TR只有4位标识可以接受的中断优先级,即16个。CPU内核只处理优先级比TR大的中断,也意味着TR每提高一个数字,就有16个中断被遮蔽!看来我们的中断要想被赶快处理,必须占个好位置。那么是不是IRQ数目越大,vector就越大呢?这是谁来决定的呢?这事可不由BIOS做主,OS是设置vector的主人。而不同的OS的处理也不近相同,我们具体看一下。
中断处理实践
Windows、Linux和BIOS在处理中断上有很多区别。我们从几个方面浮光掠影了解一二。
中断向量设置
PIC如何设置中断向量已经过时,我们就不提了。这里只介绍APIC模式,如果你还记得上节关于IOAPIC的内容,其中最重要的PRT表,它由24个RTE( RedirectionTableEntry)项组成,每一项对应一个IRQ引脚。它的内容除了上节介绍过的Destination Field之外,最低8位是该IRQ对应的vector,可以表示256个vector。OS根据自己的策略,为IRQ分配不同的vector。
1. Windows:
Windows的HAL在设置vector时是根据系统枚举硬件时挨个设置的,因为先枚举的设备其IRQ的大小不确定,所以优先级并无一定之规。从vector不能推导IRQ,IRQ也不能推导vector,可以说全凭运气。为硬件IRQ分配的vector往往从0x31开始分配,应该是为了配合Windows的IRQL概念。大家可以在windbg里输入命令
!idt -a
查看一下自己机器的vector分配情况。这个IRQL比较让人混淆,实际上它并不是个硬件概念,和中断优先级并不同,它是微软定义的一套软件优先级方案。Windows用0到31来表示优先级,数值越大,优先级越高。如下图:
其中DPC/Dispatch是个分水岭,运行在这个优先级的线程不会被其他线程抢占。其上3到26是为了外围硬件保留的。最高的31显得很高大上,谁的地位这么高?你一定见过它,它就是在Windows蓝屏时的IRQL。HAL会把IRQL翻译到不同的硬件平台上,它和X86的中断优先级不是一个概念。
2. Linux:
Linux没有IRQL的概念,他的vector就从0x20(32)开始分配,但是因为0x80(128)因为历史原因,被保留做系统调用(后改用sysenter指令,但为了兼容,还是保留),整个空间被一份为二。后面到0xee(238)为止。因为vector的大小关系到优先级,分配的时候为了保证对各个IOAPIC公平,分配的时候在各个IOAPIC间轮流分配。大家可以在shell里输入以下命令查看一下中断向量的分配情况:
cat /proc/interrupts
2
IRQ在多处理器的分发
还有个问题十分重要。某个vector由哪个CPU内核负责处理呢?Linux为了公平起见,并不会对BSP(bootstrap processor)另眼看待,所有内核一视同仁。Linux通过填写IOAPIC的RTE中的Delivery mode选择最低优先级策略,让TRP都被初始化做固定值,因此IRQ信号就可以公平的在CPU之间分发。感觉很民主有没有?(分分钟被Linus的独裁作风打脸)。有时为了优化性能,我们可以通过Linux的IRQ亲缘性来让特定内核为我们服务。我们可以通过命令
cat /proc/irq/xx/smp_affinity
查看xx IRQ由谁处理,如果是f的话代表是缺省策略,即大家都可以处理。我们可以通过下面命令分配个专有内核处理
echo 2 >/proc/irq/xx/smp_affinity
让APIC ID为2的内核处理。或者通过一些Irqbalance类似的工具来帮我们配置。
3
UEFI固件中的中断
UEFI固件内核中对异常和中断都有处理,还包含很多使用IPI调度内核的源程序,程序短小精干,包括大量注释。感兴趣的同学可以通过它学习中断处理和CPU内核调度。UEFI对中断的初始化和使用都在CPU的开发包里:
https://github.com/tianocore/edk2/tree/master/UefiCpuPkg
有几个地方值得注意,我们来一一看看。
1. 异常
UEFI内核对IDT的初始化程序在UefiCpuPkg的Library/CpuExceptionHandlerLib下。内核为所有的的中断和异常都分配了统一的入口CommonExceptionHandler。它对任何中断和异常没有任何特殊处理,如果没有人对该中断或异常做处理就会dump一些现在的CPU状态如APIC ID, 异常类型等,然后调用CpuDeadLoop陷入死循环,这也是UEFI工程师常见的画面。UEFI驱动可以在自己关心的异常中添加自己的处理函数,如支持通过串口和USB源程序级调试UEFI程序的Source Level Debugger就是个典型的例子,它Hook住了很多异常,用于调试和捕捉错误,它在:
https://github.com/tianocore/edk2/tree/master/SourceLevelDebugPkg
2. 中断
UEFI的CSM模块还兼容以前BIOS使用的INT x软中断方式调用BIOS服务。随着UEFI的广泛推广和传统OS的渐渐淘汰,CSM也日薄西山,有些仅仅面向最新OS的项目都不含CSM的支持,所以关于它的内容这里略过。在保护模式下,UEFI内核仅仅对时钟中断进行了处理,并通过Timer Architectural Protocol开放出来供所有UEFI程序调用。也许你会好奇,那么多种USB设备和网卡等等的UEFI驱动难道不需要中断处理?是的,他们的中断在UEFI阶段都没有开启,他们的驱动通过Timer加Polling的方式来处理。举个例子,我们在UEFI Shell 下插入键盘,它能立刻起作用不是如在OS中USB控制器产生了中断。而是USB驱动注册了个Timer,过一会就Poll一下看看有没有新设备插入。就是这个Timer发现了新插入的键盘的。
这种仅仅依靠Timer的做法在OS阶段是行不通的,会带来严重的效能和功耗问题。但是在Boot阶段却问题不大,而且这样做保证了UEFI内核的简洁性。事实上,UEFI并不禁止驱动自己开启中断,但开启中断需要处理的中断共享、IOAPIC设置等等问题需要驱动自己解决,UEFI并不提供支持。
3. IPI
内核可以通过写自己LAPIC的ICR(Interrupt Command Register)发出IPI((Inter-Processor Interrupt)调度别的内核完成任务,这也是任务调度的基本方法。事实上,因为APIC ID的不连续性,我们正是通过发送IPI的方法来统计内核的数量。BSP在启动时需要统计系统中可用的内核时,发送广播IPI,让大家都来报道,BSP开始点数,1,2,3。。。并一一记录在案。在启动OS前,通过ACPI table告诉OS有多少个内核。OS不应该自己统计内核数目,事实上固件可以通过瞒报内核的方式将部分内核挪作他用,但谁会这么做呢?
如何发起IPI在CPU package里有大量实例和库,大家可以参考。
其他
一些容易混淆的名词这里要特别说明一下
IRQ x:起源于PIC,指中断引脚,后在APIC时代沿用,泛指中断号。
Vector x/INT x: X是中断向量,如前文所说 IRQ不等于INT和vector.
PIRQ: PCI IRQ。它是描述南桥内部PCI设备的IRQ配置关系的。我们下一篇文章介绍。
GSI:Global System Interrupt,是ACPI spec规定的全局中断表。它为多IOAPIC情况下确定了系统唯一的一个中断号。例如IOAPIC1有24个IRQ,IOAPIC2也有24个IRQ,则IOAPIC2 的GSI是从24开始,GSI = 24 + IRQ(IOAPIC2)。
SCI:System Control Interrupt,系统控制中断,是ACPI定义的,专用于ACPI电源管理的一个IRQ。它在Intel平台上常常与南桥的电源管理模块一起,当外部EC等发生Event后会引发SCI。Windows的SCI ISR程序就是著名的acpi.sys。acpi.sys在收到SCI后会检查GPE状态寄存器以确定是谁引发的event,然后按照ACPI spec要求调用相应Method。详情请参照ACPI spec。可以认为SCI是ACPI定义的所有电源管理事件的总入口,它对应的IRQ在一般情况下是不能修改的。SCI是如何报告和简单的GPE method我们在下一篇中会详细介绍。
结语
说了这么多,如果我们从硬件和软件方面,梳理整个中断设置和处理的链条,会发现还有个环节没有解决。那就设备的IRQ是谁来决定的?是硬件hard wired?还是软件可以配置?OS是如何知道这些信息的呢?OS又是怎么知道IOAPIC的数目和位置的呢?这些都是UEFI固件需要解决的问题,我们在下一篇文章中会详细说明。在此之前,如以往一样,有几个思考问题可以让大家加深对中断和UEFI的理解:
1. 中断的引入,必然带来了代码重入的问题。我们知道,这可以通过设定优先级、信号量/临界区等等办法来解决。UEFI是通过什么方法呢?TPL和IRQL的相似和区别又是什么呢?
2. UEFI内核还不支持多线程,我们如果需要增加多线程调度,仅仅依靠时钟中断,够不够用呢?
3. OS利用缺页异常可以调度内存到硬盘上和实现Lazy loading等等实用的功能。UEFI的SMM内核也开启了缺页异常,但是却为了另外一个目的,你能看出是为了什么吗?